2024 再探ObjC-Category:动态特性与运行时实现的极致之美

由 布多(budo) 发布于 2024-12-11 • 最后更新于2025-01-09

前言

Category 是 ObjC 中一个基础且重要的概念。本文将从 Runtime 源码入手,向你介绍 Category 的概念以及底层的实现原理。

Category 概念

Category 主要是用来给已存在的类动态添加方法实现,也可扩展协议和属性。基于此特性,我们可以用 Category 实现如下功能:

  • 将一个庞大的类分解成多个 Category,每个 Category 只完成少量的任务,从而提高模块化和代码解耦程度。

  • 在不继承的情况下给已有类动态的添加新方法。

  • 模拟多继承,比如让已有类支持新协议。

Category 之编译期实现细节

创建一个 ObjC 源代码文件并将其命名为 test_category.m,然后在文件内输入如下代码:

#import <Foundation/Foundation.h>

@interface NSObject (WXLCategory)<NSCopying>

@property () NSInteger wxl_ist_prot;

@property (class) NSInteger wxl_cls_prot;

- (void)wxl_ist_func;
+ (void)wxl_cls_func;

@end

@implementation NSObject (WXLExtension)

- (void)wxl_ist_func {}
+ (void)wxl_cls_func {}

@end

这里我特意只写了方法的实现,而没有写属性和协议的实现,后面会解释为什么。

使用 xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc test_category.m 命令可以将上述代码编译为 C++ 文件,代码精简后如下所示:

struct category_t {
const char *name;
struct class_t *cls;
const struct method_list_t *instance_methods;
const struct method_list_t *class_methods;
const struct protocol_list_t *protocols;
const struct prop_list_t *instanceProperties;
// Fields below this point are not always present on disk.
const struct prop_list_t *_classProperties;
};

static struct category_t _OBJC_$_CATEGORY_NSObject_$_WXLCategory
__attribute__ ((used, section ("__DATA, __objc_const"))) =
{
"NSObject",
0,
(const struct method_list_t *)&_OBJC_$_CATEGORY_INSTANCE_METHODS_NSObject_$_WXLCategory,
(const struct method_list_t *)&_OBJC_$_CATEGORY_CLASS_METHODS_NSObject_$_WXLCategory,
(const struct protocol_list_t *)&_OBJC_CATEGORY_PROTOCOLS_$_NSObject_$_WXLCategory,
(const struct prop_list_t *)&_OBJC_$_INSTANCE_PROP_LIST_NSObject_$_WXLCategory,
(const struct prop_list_t *)&_OBJC_$_CLASS_PROP_LIST_NSObject_$_WXLCategory,
};

_classProperties 变量是我参考 Runtime 源码后手动加上的,你编译的代码可能会没有。

从编译后的代码中不难看出,一个 Category 对象,其底层其实就是1个 category_t 的结构体对象,这个结构体中包含了实例属性、类属性、实例方法、类方法以及协议等变量用来保存分类中的相关数据。

最后,编译器会把 category_t 相关数据保存在 Mach-O 文件的 objc_const 数据段下,等待运行时解析。

Category 之运行时实现细节:探索内部实现原理

相关代码整理后如下所示(代码有点长,不想看可以跳过,后面有解释):

本文使用的 Runtime 源码出自 objc4-928.2,为了方便大家阅读,我会对代码样式和排版略作修改以及删减一些不影响代码主逻辑的冗余代码。

我在 这里 维护了一个可以直接运行调试的 Runtime 项目,方便大家直接调试源码。

void
load_images(const struct _dyld_objc_notify_mapped_info* info) {
// 检查该模块是否有 +load 方法的实现。
if (!hasLoadMethods((const headerType *)info->mh,
info->sectionLocationMetadata)) return;

// 加载所有分类。
loadAllCategoriesIfNeeded();
}

static bool didInitialAttachCategories = false;

void loadAllCategoriesIfNeeded() {
// 控制不要重复加载分类数据,每加载一个模块时都可能会来到这里。
if (!didInitialAttachCategories) {
/*
遍历所有模块并加载它们的分类数据。

注意:在加载第1个模块时就会执行该函数,
这意味着在加载第1个模块时就会把所有模块中的分类数据都加载,
而不是只加载当前模块中的分类数据。
*/
for (auto *hi = FirstHeader; hi != NULL; hi = hi->getNext()) {
load_categories_nolock(hi);
}

didInitialAttachCategories = true;
}
}

static void load_categories_nolock(header_info *hi) {
// 当前模块是否有分类类属性。
bool hasClassProperties = hi->info()->hasCategoryClassProperties();

size_t count;

// 遍历当前模块中的所有分类数据并进行处理。
auto processCatlist = [&](category_t * const *catlist) {
for (unsigned i = 0; i < count; i++) {
category_t *cat = catlist[i];
Class cls = remapClass(cat->cls);

// 把 cat、cls、hi 包装一下,方便后面调用函数时传参。
locstamped_category_t lc{cat, cls, hi};

// 检查这个分类中是否有实例方法、协议、实例属性。
if (cat->instanceMethods ||
cat->protocols ||
cat->instanceProperties) {
// 检查 cls 是否已实现,
if (cls->isRealized()) {
// 将分类中的实例方法、协议、实例属性添加到 cls 上。
attachCategories(cls, &lc, 1, cls, ATTACH_EXISTING);
} else {
// 将分类数据和类对象保存起来,等类对象实现后再进行加载。
objc::unattachedCategories.addForClass(lc, cls);
}
}

/*
检查这个分类中是否有类方法、协议、类属性。

注意 `(hasClassProperties && cat->_classProperties)` 这段代码,
可能是因为使用类属性的项目非常少,所以加入了这一个判断。

这里是在给元类对象添加方法和属性,但是,元类对象是没有协议的。不清楚这
里为什么要判断 `cat->protocols`。

另外,在后面获取分类的协议列表时也有判断,如果是给元类添加的话就直接返回
NULL。感觉这段代码其实可以删掉,不知道是否有其它隐情?
*/
if (cat->classMethods ||
cat->protocols ||
(hasClassProperties && cat->_classProperties)) {
if (cls->ISA()->isRealized()) {
// 将分类中的类方法、类属性加载到元类上。
attachCategories(cls->ISA(), &lc, 1, cls,
ATTACH_EXISTING | ATTACH_METACLASS);
} else {
// 将分类数据和元类对象保存起来,等元类对象实现后再进行加载。
objc::unattachedCategories.addForClass(
lc.reSignedForMetaclass(cls),
cls->ISA());
}
}
}
};

processCatlist(hi->catlist(&count));
}

/*
cls: 需要把分类数据添加到哪个类上。
如果添加的是实例方法、实例属性、协议,这个参数就是类对象;
如果添加的是类方法、类属性,这个参数就是元类对象。

cats_list: 需要被添加的分类数据,注意这是一个数组。

catsListKey: 分类所属的类对象。
不管添加的是实例方法还是类方法,始终指向该分类所属的类对象,不会是元类对象。
*/
static void
attachCategories(Class cls,
const locstamped_category_t *cats_list,
uint32_t cats_count,
Class catsListKey,
int flags) {
constexpr uint32_t ATTACH_BUFSIZ = 64;

/*
一个临时的缓存结构。分类中的方法、属性、协议会被临时添加到这个缓存对象中去,
当缓存容量满了,或者分类中的数据加载完了,再一次性添加到类中去。
*/
struct Lists {
ReversedFixedSizeArray<method_list_t *, ATTACH_BUFSIZ> methods;
ReversedFixedSizeArray<property_list_t *, ATTACH_BUFSIZ> properties;
ReversedFixedSizeArray<protocol_list_t *, ATTACH_BUFSIZ> protocols;
};

Lists normalLists;

bool isMeta = (flags & ATTACH_METACLASS);
auto rwe = cls->data()->extAllocIfNeeded();

for (uint32_t i = 0; i < cats_count; i++) {
// entry 的原型:locstamped_category_t {cat, cls, hi}
auto& entry = cats_list[i];

// 获取分类中的方法列表。
method_list_t *mlist = entry.getCategory(catsListKey)->methodsForMeta(isMeta);
Lists *lists = &normalLists;
bool isPreattached =
entry.hi->info()->dyldCategoriesOptimized() && !DisablePreattachedCategories;

if (mlist) {
if (lists->methods.isFull()) {
// 将缓存中的方法全部添加到类/元类中。
rwe->methods.attachLists(lists->methods.array,
lists->methods.count,
isPreattached,
PrintPreopt ? "methods" : nullptr);

// 清空缓存。
lists->methods.clear();
}

// 将分类中的方法添加到缓存。
lists->methods.add(mlist);
}

// 获取分类中的属性列表。
property_list_t *proplist =
entry.getCategory(catsListKey)->propertiesForMeta(isMeta, entry.hi);
if (proplist) {
if (lists->properties.isFull()) {
// 将缓存中的属性全部添加到类/元类中。
rwe->properties.attachLists(lists->properties.array,
lists->properties.count,
isPreattached,
PrintPreopt ? "properties" : nullptr);

lists->properties.clear();
}

// 将分类中的属性添加到缓存。
lists->properties.add(proplist);
}

// 获取分类中的协议列表,内部会通过 isMeta 判断如果是元类就返回 NULL。
protocol_list_t *protolist =
entry.getCategory(catsListKey)->protocolsForMeta(isMeta);
if (protolist) {
if (lists->protocols.isFull()) {
// 将缓存中的协议全部添加到类/元类中。
rwe->protocols.attachLists(lists->protocols.array,
lists->protocols.count,
isPreattached,
PrintPreopt ? "protocols" : nullptr);

lists->protocols.clear();
}

// 将分类中的协议添加到缓存。
lists->protocols.add(protolist);
}
}

// 将缓存里的方法、属性、协议全部添加到类/元类中。
auto attach = [&](Lists *lists, bool isPreattached) {
// 将缓存里的方法添加到类/元类中。
rwe->methods.attachLists(lists->methods.begin(),
lists->methods.count,
isPreattached,
PrintPreopt ? "methods" : nullptr);

// 将缓存里的属性添加到类/元类中。
rwe->properties.attachLists(lists->properties.begin(),
lists->properties.count,
isPreattached,
PrintPreopt ? "properties" : nullptr);

// 将缓存里的协议添加到类中。
rwe->protocols.attachLists(lists->protocols.begin(),
lists->protocols.count,
isPreattached,
PrintPreopt ? "protocols" : nullptr);
};

attach(&normalLists, false);
}


/*
这个函数的作用是把方法、属性、协议添加到类/元类中,
不一定是添加分类中的数据,也有可能是添加类自身的数据。
*/
void attachLists(List* const * addedLists,
uint32_t addedCount,
bool preoptimized,
const char *logKind) {
if (addedCount == 0) return;

// 这个分支通常是用来添加类自身的方法、属性、协议。
if (storage.isNull() && addedCount == 1) {
storage.set(*addedLists);

// 这个分支通常是用来处理第1个分类的数据。
} else if (storage.isNull() || storage.template is<List *>()) {
// 0 or 1 list -> many lists

// 获取旧数据。
List *oldList = storage.template dyn_cast<List *>();
uint32_t oldCount = oldList ? 1 : 0;
uint32_t newCount = oldCount + addedCount;

// 开辟一个新数组,足以容纳旧数据加上分类中的新数据。
array_t *array = (array_t *)malloc(array_t::byteSize(newCount));
storage.set(array);
array->count = newCount;

/*
将类中原来的数据(方法、属性、协议)放到数组最后面。
这一步保证了类自身的数据永远处于列表的最后面。
*/
if (oldList) array->lists[addedCount] = oldList;

// 将分类中的数据插入到数组的前面。
for (unsigned i = 0; i < addedCount; i++)
array->lists[i] = addedLists[i];

// 这个分支通常是用来加载第2个及之后的分类数据。
} else if (array_t *array = storage.template dyn_cast<array_t *>()) {
// many lists -> many lists
uint32_t oldCount = array->count;
uint32_t newCount = oldCount + addedCount;
array_t *newArray = (array_t *)malloc(array_t::byteSize(newCount));
newArray->count = newCount;

// 把旧数据按照之前的顺序放到新数组的最后面。
for (int i = oldCount - 1; i >= 0; i--)
newArray->lists[i + addedCount] = array->lists[i];

/*
将分类数据依次添加到数组的前面,
这一步操作会导致最后编译的分类数据将会被添加在数组的最前面。
*/
for (unsigned i = 0; i < addedCount; i++)
newArray->lists[i] = addedLists[i];

free(array);
storage.set(newArray);

/*
一般情况下不会来到这个分支,
在之前的函数 load_categories_nolock 中有这样一行代码:
`objc::unattachedCategories.addForClass(lc, cls);`
这个分支就是用来处理这种情况。
*/
} else if (auto *listList = storage.template dyn_cast<relative_list_list_t<List> *>()) {
// list-of-lists -> many lists
auto listListBegin = listList->beginLists();
uint32_t oldCount = listList->countLists();
uint32_t newCount = oldCount + addedCount;
array_t *newArray = (array_t *)malloc(array_t::byteSize(newCount));
newArray->count = newCount;

uint32_t i;
for (i = 0; i < addedCount; i++) {
newArray->lists[i] = addedLists[i];
}

for (; i < newCount; i++) {
newArray->lists[i] = *listListBegin;
++listListBegin;
}

storage.set(newArray);
}
}

从源码中不难发现,Category 中的数据(方法、属性、协议)都是在运行时通过 Runtime 动态添加到类中一个叫做 rwe 的对象中。

在 rwe 这个对象中拥有三个变量,分别是:方法列表、属性列表、协议列表,这个变量其实就是一个二维数组。以方法列表为例,类自身的所有方法是一个数组,每个 Category 中的所有方法是一个数组,它们都被放在这个二维数组中,注意,类自身的方法列表放在这个二维数组的最后面,最后编译的那个 Category 中的方法列表放在这个二维数组的最前面。

正是因为这个特点才导致了 Category 中的方法实现会覆盖与类本身同名的方法实现。所以,在开发过程中我们经常会看到很多框架都会给 Category 的方法和属性添加前缀,其目的就是为了降低重名的可能性。

有些人说 Category 不支持添加实例变量是因为 category_t 结构体中没有 ivars 字段。其实并不是添加一个字段的事,根本原因是因为开发者可能会用 Category 给已经编译好的类(例如系统类)添加数据,而这些类的内存布局与地址已经固定死了,如果要给它添加实例变量势必要修改其内存布局与地址。

另外,也不能给 Category 添加 weak 属性,如果一定要添加 weak 属性的话,可以采用中间者模式,即给 Category 添加一个中间者对象,然后给这个中间类声明一个 weak 属性。关于 weak 指针的更多细节请看我的另一篇文章 揭开 iOS 中 weak 指针的神秘面纱:从原理到实践

我在网上看到有些人说为什么要把 Category 设计成使用 Runtime 运行时加载,直接设计成编译时加载不是更好吗?他们的想法是:“在给项目中某个类(这个类是在项目中创建的),例如 CustomClass 创建分类时,编译器其实能拿到 CustomClass 的实现文件,那么只要把分类中的方法和这个类自身的方法合并不就行了,这样还能实现在 Category 中给这个类添加实例变量。” 乍一看没啥问题。但是,Category 还支持给已经编译好的类(例如系统类)添加方法实现,而这些类的布局和地址已经固定死了,因而不能这么干。

在阅读源码的过程中,我还发现了一些其它问题:

  1. 在前面的 test_category.m 文件中,我特意没有在实现中写上属性和协议的实现。因为经过我的调试发现,Runtime 在解析 Category 中的属性和协议时,只看声明并不看实现,只要有属性、协议声明,不管有没有实现都会被添加到类的属性列表和协议列表中。但是在解析方法的时候是反过来的,只会把有方法实现的那些方法添加到方法列表中。

  2. 在第1个函数 load_images 中有这么一个判断:如果该模块中没有 +load 方法实现就不添加 Category 数据。我查阅了许多资料,但是都没有找到可信的证据解释为什么要这样做?如果你知道为什么的话还请留言告知。

  3. Runtime 会在 loadAllCategoriesIfNeeded 函数内一次性加载所有模块中的分类数据,而不是遍历一个模块加载一个模块中的分类数据。我想了一下,这么做有以下好处:降低程序的复杂度和提高性能。如果是遍历一个模块加载一个模块的数据,那就不能只使用一个全局变量 didInitialAttachCategories 来标记分类数据是否已加载?可能要维护一个字典,例如 key 是模块名称,value 表示该模块是否已加载分类。这么做显然比维护一个全局变量成本更高。

Category 与 Extension 的区别

经常有人把 Category 和 Extension 拿到一起来说,可能是因为它们的声明方式有点像吧,以下是 Category 和 Extension 的声明代码:

// Category 声明
@interface Person (CategoryName) @end

// Extension 声明
@interface Person () @end

从代码来看,Category 似乎只多了一个 name 而已。所以导致很多人以为它们的底层实现可能差不多,但其实它们的实现压根不一样。

Extension 的特点

从功能和底层实现上来看,其实 Extension 和 Interface(类声明) 更像一些,Interface 能干的事,它基本上都能干,除了不能指定父类。

Interface 一般是用来对外提供接口数据,但有时候我们会想把一些属性、方法、实例变量隐藏起来。Extension 就是专门用来干这个的,因为 Interface 只能有一个,但 Extension 可以有多个。Extension 和类声明都是编译特性,你可以在 Extension 中声明实例变量、属性、方法、协议,这和在 Interface 中写本质上是一样的。

需要注意一点,虽然在 Extension 中可以声明实例变量,但仅在拥有 implementation 实现的这个文件中这么做才可以,例如以下代码就可以,因为这个文件中拥有 Book 的实现:

@interface Book () {
NSString *_name;
}
@end

@implementation Book
- (void)testFun {
_name = @"";
NSLog(@"%@", _name);
}
@end

例如下面的代码就不行,因为这个文件中没有 Book 类的实现,此时你会得到一个编译错误:

@interface Book () {
NSString *_name;
}
@end

@implementation Book (CategoryName)
- (void)testFun {
_name = @"";
NSLog(@"%@", _name);
}
@end

另外,虽然在任何地方都能使用 Extension,但是和上面的实例变量一样,如果需要编译器自动生成实现代码(例如属性),那就不能在 implementation 之外的文件使用,切记!!!

Extension 还有一个好用的功能就是声明私有方法,这样就能在后面的代码中直接调用这个方法了,而不是写成这样:[self performSelector:@selector(testFun)],网上有很多人是使用 Category 干这个事,其实 Extension 也可以,个人感觉这样更优雅。

Category 的特点

与 Extension 相比,Category 是编译器加上 Runtime 共同完成的。编译器负责将 Category 编译成 category_t 对象,然后添加到 Mach-O 文件中。Runtime 负责在运行时将 category_t 中的数据解析并添加到对应的类中。

总结

从苹果提供的源码中我们不难发现,其实 Category 的底层实现并不复杂,其本质就是将 Category 转化成一个结构体用来保存相关数据(属性、方法、协议),然后通过 Runtime 在运行时将这个结构体中的数据解析出来并且添加到类中。而这个类对象内部有一个二维数组来存储每个分类中的数据。

文章作者: 布多
文章链接: https://budo.top/2024/12/11/iOS/2024 再探ObjC-Category:动态特性与运行时实现的极致之美/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 布多的博客